从开篇的《浅谈容器技术圣经-OCI》到《OCI Image Format Specification Part1-物理解剖容器Image》再到最新的《管窥蠡测之OCI Image Format Specification》,承蒙大家错爱。三篇内容截止到现在也有1000+的阅读量,这个阅读量说实话有点出乎我的意料,而且每天都新增关注的follower,内心还是暗自窃喜。不过还是非常感谢大家的关注。
我原先计划在OCI Image Spec分两部分内容来剖析,先以busybox的例子为base,然后再浓缩凝练官方OCI Image Format Spec V1.1.1。本来是要开始 OCI Runtime Specification的内容了,我临时改变了主意,在上一篇《管窥蠡测之OCI Image Format Specification》的最后加了基于一个scratch的空白镜像,用Debian的rootfs做一个golang的基础镜像的预告。我知道在实际工作中几乎不可能有手搓容器镜像的场景,大家对容器运行时的使用也到了炉火纯青的地步,写Dockerfile都是信手拈来。不过我猜很少会有人去主动关注类似buildkit这类的幕后英雄,因为它的存在我们才能用Dockerfile制作出我们想要的符合OCI标准的容器镜像。不过这次我并不想对buildkit的代码展开研究,我想通过手搓一个镜像的方式来加深大家对OCI Image Spec的理解,顺便展示buildkit的大致工作原理。
要达成本次的目标大致有三个步骤:
下面的内容都是围绕上述的三步内容展开。
ENTRYPOINTFROM scratch
ADD rootfs.tar.xz /
CMD ["bash"]
虽然这个Dockerfile只有短短的三行,但是通过buildkit的操作之后,它摇身一变就变成了一个带有debian OS的镜像。这里我想强调的是容器镜像的FROM xxxx的内容含义是指定一个基础镜像,但是这个基础镜像并非无中生有,也是需要一个Dockerfile来构建成基础镜像。我们看到的Ubuntu,Centos,Alpine,Debian等的Linux的发行版的容器镜像几乎都是这么做出来的。先对OS的源码进行编译然后做成一个rootfs的压缩包,再通过Dockerfile,让buildkit执行制做出对应OS的基础镜像。
第一行的FROM scratch 中的scratch这个镜像其实物理上是不存在的,你可以使用docker pull命令尝试去拉取它,你发发现docker daemon会给你返回带有 Error response from daemon: 'scratch' is a reserved name关键字的报错信息。这是因为从docker 1.5版本开始,Dockerfile中的FROM scratch 指令就不执行任何操作了。大家可能都知道在Dockerfile中添加一行操作对应到容器镜像会产生一个layer层,不过这里并不是所有的命令都会产生一个layer层的,比如说这里提到的FROM scratch和ENV添加环境变量操作,不知道大家是否还记得我在《OCI Image Format Specification Part1-物理解剖容器Image》中讲 Image Config内容的时候提到了history数组对象
"history": [
{
"created": "2024-09-26T21:31:42Z",
"created_by": "BusyBox 1.37.0 (glibc), Debian 12"
}
],
这里其实对应的就是Dockerfile中每一行的操作,大家注意一下history[any].empty_layer字段的内容,这里就表示是否是一个独立layer层的意思,如果是false就是独立的layer层,反之则不是。
如:
{
"created": "2025-07-14T22:07:26Z",
"created_by": "ENV NGINX_VERSION=1.29.0",
"comment": "buildkit.dockerfile.v0",
"empty_layer": true
},
我在这里想说明的就是Dockerfile的命令并不是每一步都会产生一个独立的layer层,在面试的时候面试官很喜欢扣这些细节,大家可以留个心眼。
第二行的ADD rootfs.tar.xz /的含义就是将rootfs.tar.xz的压缩包解压至根目录中,巧了,这里又有一个面试官喜欢拿来拷打我们的点,就是Dockerfile中ADD和COPY的区别,其实两者的区别就在于解压这个动词中,ADD操作会自动解压压缩包的内容到destination path,但是COPY不会,就是字面意思的拷贝。大家可以继续留一个心眼。
第三行CMD ["bash"]这一行是必须的,因为容器中必须要有一个运行的进程。这里也有一个面试的考点,就是关于CMD和ENTRYPOINT的区别:
CMD: 设置容器启动时的默认命令或参数。如果在 docker run 命令后指定了其他命令,会覆盖 CMD 中的内容。ENTRYPOINT: 设置容器启动时的固定命令。docker run 后面的参数会作为额外参数传递给 ENTRYPOINT 命令,而不会覆盖它。我用一个例子来直观的展示两者的区别:
ENTRYPOINT 通常与 CMD 结合使用,ENTRYPOINT 定义固定命令,CMD 提供默认参数:
FROM alpine
ENTRYPOINT ["echo", "Hello"]
CMD ["World"] # 默认参数
docker run myimage → 输出 Hello Worlddocker run myimage Docker → 输出 Hello Docker(CMD 的默认参数被替换)如果大家对rootfs.tar.xz的来历感兴趣的话,大家可以可以去看一下构建 debian 基础镜像的 Jenkins 流水线任务 debuerreotype(https://doi-janky.infosiftr.net/job/tianon/job/debuerreotype),上面有构建这个 rootfs.tar.xz 完整过程,或者参考 Debian 官方的 docker-debian-artifacts 这个 repo 里的 shell 脚本。
大家直接下载最新的Debian的rootfs.tar.xz ,然后用下面的内容执行docker build -t debian:latest命令就能得到Debian的基础容器镜像:
ENTRYPOINTFROM scratch
ADD rootfs.tar.xz /
CMD ["bash"]
本文的核心内容几乎都在Step2,开始之前我们先捋一捋实现思路:
我们使用的镜像架构是继续amd64的,包括golang的版本也是amd64的。
先准备一个实验的工作区环境,我们指定在$HOME/OCI路径下,这里要声明下,我跳过了第一步的手动操作,选择直接从dockerhub上下载源镜像,其实结果是一样的,如果大家想要享受手磨镜像的乐趣,可以选择老老实实按照step1的步骤手搓。鄙人还是选择从repository pull现成的:
mkdir $HOME/OCI && cd $HOME/OCI && skopeo --insecure-policy copy docker://docker.1ms.run/debian:10 oci:$(pwd)/debian:10
在 $HOME/OCI路径下我们可以看到一个名为debian的文件夹,如下所示:
tree -L 1 debian/
debian/
├── blobs
├── index.json
└── oci-layout
2 directories, 2 files
这个文件编排大家现在应该很熟悉了。我们还是老样子,从FAT Manifestindex.json入手,一层层的往下找,先找到manifest清单:
cat index.json | jq
{
"schemaVersion": 2,
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:78b449dd95c5608607e3e4db60cd573c8696832ecc31754235818eed227cbda3",
"size": 406,
"annotations": {
"org.opencontainers.image.ref.name": "10"
}
}
]
}
根据上面的内容,得知Manifest镜像清单的路径是blobs/sha256/78b449dd95c5608607e3e4db60cd573c8696832ecc31754235818eed227cbda3,下面是镜像清单的内容:
cat blobs/sha256/78b449dd95c5608607e3e4db60cd573c8696832ecc31754235818eed227cbda3 | jq
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:2f0f63594d927b568306a92b85fcd8f6d575cf406e0888f95212fcd660aece3b",
"size": 579
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:3892befd2c3f36ceb247ba7d906de12601d69b806597e65c4c837cf3d93df119",
"size": 50657373
}
]
}
golang:1.25的下载URL: https://studygolang.com/dl/golang/go1.25.0.linux-amd64.tar.gz ,我计划golang1.25的环境解压到容器中的/usr/local/lib路径下。
cd $HOME/OCI && \
wget https://studygolang.com/dl/golang/go1.25.0.linux-amd64.tar.gz && \
mkdir -p usr/local/lib && \
tar -zxvf go1.25.0.linux-amd64.tar.gz -C usr/local/lib && \
tar -zcvf go1.25.0.linux-amd64.tar.gz usr && \
rm -rf usr
拷贝go1.25.0.linux-amd64.tar.gz到$HOME/OCI/debian/blobs/sha256/路径下 然后计算sha256的hash值
cp go1.25.0.linux-amd64.tar.gz $HOME/OCI/debian/blobs/sha256/ && shasum -a 256 $HOME/OCI/debian/blobs/sha256/go1.25.0.linux-amd64.tar.gz
b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec /root/OCI/debian/blobs/sha256/go1.25.0.linux-amd64.tar.gz
重新rename $HOME/OCI/debian/blobs/sha256/go1.25.0.linux-amd64.tar.gz
mv $HOME/OCI/debian/blobs/sha256/go1.25.0.linux-amd64.tar.gz $HOME/OCI/debian/blobs/sha256/b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec
顺便计算一下新layer的大小,后面的步骤要用到这个layer的size信息。
wc -c $HOME/OCI/debian/blobs/sha256/b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec
60606282
layer的大小是60606282字节
从3.2.1 镜像转换为OCI格式我们可以看出image config的路径是 blobs/sha256/2f0f63594d927b568306a92b85fcd8f6d575cf406e0888f95212fcd660aece3b,内容如下:
cat $HOME/OCI/debian/blobs/sha256/2f0f63594d927b568306a92b85fcd8f6d575cf406e0888f95212fcd660aece3b | jq
{
"created": "2024-06-13T01:21:29.146379431Z",
"architecture": "amd64",
"os": "linux",
"config": {
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": [
"bash"
]
},
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:19b1230ac19da0b27c923fa01d30578f13ca2b309e8b25f04023e433df628c70"
]
},
"history": [
{
"created": "2024-06-13T01:21:28.635932821Z",
"created_by": "/bin/sh -c #(nop) ADD file:82f06126089fd0ca482c29baeb90ef37ac3a9f5f6a0f2f5c968a605846627d47 in / "
},
{
"created": "2024-06-13T01:21:29.146379431Z",
"created_by": "/bin/sh -c #(nop) CMD [\"bash\"]",
"empty_layer": true
}
]
}
这里我们要更新的内容有两个地方,一个是rootfs的数组对象,另外一个就是history的数组对象。我们现在来逐个击破。
在《管窥蠡测之OCI Image Format Specification》中,我提到了diff_ids和manifest的layers的digest的区别,diff_ids数组中记录的是tar类型的sha256哈希值。所以这里我们就需要在 集成golang:1.25的环境步骤中copy出来的1.25.0.linux-amd64.tar.gz还原成tar文件类型。
gunzip -d $HOME/OCI/go1.25.0.linux-amd64.tar.gz && ls $HOME/OCI/
debian go1.25.0.linux-amd64.tar
我们将1.25.0.linux-amd64.tar.gz还原成了 go1.25.0.linux-amd64.tar,接下来我们需要计算sha256的内容,命令如下:
shasum -a 256 $HOME/OCI/go1.25.0.linux-amd64.tar
663572620de113ba7853ffdf5627aec98cac0f1e97e4d0a50efc306d41a38aaf /root/OCI/go1.25.0.linux-amd64.tar
662a97196bf11cc934cc544aae83f805b1a320508c751e23d4119adbb9580146这个内容需要添加到diff_ids的数组中,注意添加的顺序,要按照顺序往下添加,如下:
"diff_ids": [
"sha256:19b1230ac19da0b27c923fa01d30578f13ca2b309e8b25f04023e433df628c70",
"663572620de113ba7853ffdf5627aec98cac0f1e97e4d0a50efc306d41a38aaf"
]
history数组对象其实记录的就是Dockerfile的操作步骤,也是按照Dockerfile的执行顺序添加的。由于我们需要用到解压操作,所以使用的时ADD的命令。将压缩包解压到容器目录的/usr/local/lib目录下,history期待变成如下的内容:
"history": [
{
"created": "2024-06-13T01:21:28.635932821Z",
"created_by": "/bin/sh -c #(nop) ADD file:82f06126089fd0ca482c29baeb90ef37ac3a9f5f6a0f2f5c968a605846627d47 in / "
},
{
"created": "2025-08-25T07:07:28Z",
"created_by": "/bin/sh -c #(nop) manual add go1.25.0.linux-amd64.tar.gz file "
},
{
"created": "2024-06-13T01:21:29.146379431Z",
"created_by": "/bin/sh -c #(nop) CMD [\"bash\"]",
"empty_layer": true
}
]
/usr/local/lib/go/bin的路径需要添加到系统PATH路径中,否则系统无法识别go的命令,Env的变更如下:
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/lib/go/bin"
],
{
"created": "2024-06-13T01:21:29.146379431Z",
"architecture": "amd64",
"os": "linux",
"config": {
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/lib/go/bin"
],
"Cmd": [
"bash"
]
},
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:19b1230ac19da0b27c923fa01d30578f13ca2b309e8b25f04023e433df628c70",
"sha256:663572620de113ba7853ffdf5627aec98cac0f1e97e4d0a50efc306d41a38aaf"
]
},
"history": [
{
"created": "2024-06-13T01:21:28.635932821Z",
"created_by": "/bin/sh -c #(nop) ADD file:82f06126089fd0ca482c29baeb90ef37ac3a9f5f6a0f2f5c968a605846627d47 in / "
},
{
"created": "2025-08-25T07:07:28Z",
"created_by": "/bin/sh -c #(nop) manual add go1.25.0.linux-amd64.tar.gz file "
},
{
"created": "2024-06-13T01:21:29.146379431Z",
"created_by": "/bin/sh -c #(nop) CMD [\"bash\"]",
"empty_layer": true
}
]
}
把上述的内容进行格式压缩去掉空格和换行,重新写回blobs/sha256/2f0f63594d927b568306a92b85fcd8f6d575cf406e0888f95212fcd660aece3b,写回后要重新计算sha256的文件哈希,然后用最新的文件hash重新命名文件。具体操作这里不展开了。我这里计算的文件哈希值是: 1ce7808dc59efbf7daec8da5f6c46becf0dfaaad6e87c62d52d4fd5f94c6fb87,更新过后的文件大小是789字节。
由于在blobs/sha256/路径下新添加了一个layer层b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec,所以原image manifest的layers数组要添加b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec层的descriptor信息,添加的内容如下:
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:3892befd2c3f36ceb247ba7d906de12601d69b806597e65c4c837cf3d93df119",
"size": 50657373
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec",
"size": 60606282
}
]
因为image config的内容已经更新,这部分的内容也需要体现在image manifest镜像清单中,config对象需要更新。
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:1ce7808dc59efbf7daec8da5f6c46becf0dfaaad6e87c62d52d4fd5f94c6fb87",
"size": 789
}
完整内容如下:
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:1ce7808dc59efbf7daec8da5f6c46becf0dfaaad6e87c62d52d4fd5f94c6fb87",
"size": 789
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:3892befd2c3f36ceb247ba7d906de12601d69b806597e65c4c837cf3d93df119",
"size": 50657373
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec",
"size": 60606282
}
]
}
把上述的内容进行格式压缩去掉空格和换行,重新写回blobs/sha256/78b449dd95c5608607e3e4db60cd573c8696832ecc31754235818eed227cbda3,写回后要重新计算sha256的文件哈希,然后用最新的文件hash重新命名文件。具体操作这里不展开了。我这里计算的文件哈希值是: a10cd3e6c96cdd1f368170589812939bbe44ebd02f367621fd64149d27e1e245,更新过后的文件大小是566字节。
{
"schemaVersion": 2,
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256: 84a867d81ef76e16469da8b49b16c0e554951c5f2686f587495c0f132107144b",
"size": 566,
"annotations": {
"org.opencontainers.image.ref.name": "10"
}
}
]
}
直接把上一步最后的哈希值84a867d81ef76e16469da8b49b16c0e554951c5f2686f587495c0f132107144b和size 566更新即可,最后把上述的内容进行格式压缩去掉空格和换行,重新写回index.json。
skopeo --insecure-policy inspect oci:$HOME/OCI/debian
{
"Digest": "sha256:84a867d81ef76e16469da8b49b16c0e554951c5f2686f587495c0f132107144b",
"RepoTags": [],
"Created": "2024-06-13T01:21:29.146379431Z",
"DockerVersion": "",
"Labels": null,
"Architecture": "amd64",
"Os": "linux",
"Layers": [
"sha256:3892befd2c3f36ceb247ba7d906de12601d69b806597e65c4c837cf3d93df119",
"sha256:b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec"
],
"LayersData": [
{
"MIMEType": "application/vnd.oci.image.layer.v1.tar+gzip",
"Digest": "sha256:3892befd2c3f36ceb247ba7d906de12601d69b806597e65c4c837cf3d93df119",
"Size": 50657373,
"Annotations": null
},
{
"MIMEType": "application/vnd.oci.image.layer.v1.tar+gzip",
"Digest": "sha256:b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec",
"Size": 60606282,
"Annotations": null
}
],
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/lib/go/bin"
]
}
如果显示上面👆🏻的内容就说明检查校验通过,恭喜你,镜像手搓成功了。
skopeo --insecure-policy copy oci:$HOME/OCI/debian docker-daemon:debian-golang:1.25
Getting image source signatures
Copying blob 3892befd2c3f done |
Copying blob b88ab3a3a61d done |
Copying config 1ce7808dc5 done |
Writing manifest to image destination
如果显示上面👆🏻的内容就说明检查校验通过,恭喜你,docker已经识别到了镜像并且导入了docker的工作区中。通过docker images命令进行double check。
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
debian-golang 1.25 1ce7808dc59e 14 months ago 320MB
mysql 5.7 5107333e08a8 20 months ago 501MB
我们可以看到我们debian-golang:1.25的镜像已经存在了。接下来就是检查golang是否正确安装,执行下面的命令:
docker run -it --name debian_go debian-golang:1.25 go version
go version go1.25.0 linux/amd64
golang的本部信息和系统架构正确输出,说明golang环境正确安装,手搓镜像环节到此就结束了,我们已经做出了debian版本的go1.25版本的镜像。
容器OCI Image Spec的内容到此也就全部结束了,写这篇博客的目的并不在于要掌握手搓镜像的技能,而是希望大家可以更好的理解OCI Image Spec,如果大家有空可以去看下moby/buildkit的实现,其实它做的事情和我们现在手搓镜像的步骤是大致相同的,buildkit比我们多做的事情就是需要去翻译Dockfile,我们手搓的时候其实是跳过这个步骤的,后面的步骤和buildkit的处理逻辑几乎是一样的。后续将开始OCI runtime的章节,敬请期待。
如果觉得内容对你有帮助,可以点击下面👇🏻的名片关注我,您的支持将会是我继续创作的动力